Rich-text (tiptap) block editor for q2-preview — Phase 1a (experiment, opt-in)#335
Merged
Merged
Conversation
Throwaway Phase-0 spike proving qmd can round-trip through a ProseMirror document for a future rich-text block editor in q2-preview. Approach: keep all existing detection + commit machinery; replace only the EditTextarea UI. Seed the PM doc from the untransformed Pandoc AST (not markdown-it), lift opaque constructs (shortcodes, math, @CrossRef, [@cite], raw inline) into verbatim "chip" nodes, serialize PM -> markdown via prosemirror-markdown. Oracle: native pampa (-t json --json-source-location full), no WASM init. Result: 15/16 exact, 1 benign reformat (blockquote softwrap), 0 broken. Stock prosemirror-markdown serializer needed zero per-node overrides beyond the chip rule. tsc clean; full preview-renderer suite (473 tests) passes. Spike lives at ts-packages/preview-renderer/src/q2-preview/tiptap-roundtrip-spike/ (throwaway; quarantine/delete before non-experimental merge). Adds dev deps prosemirror-model + prosemirror-markdown. Plan + verdict: claude-notes/plans/2026-06-23-tiptap-rich-text-block-editor.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AST->ProseMirror->markdown bridge for the rich-text block editor, reusing the Phase-0 spike's validated approach but as production modules keyed to tiptap's node/mark names (so one bridge + serializer serve both the test schema and the live tiptap editor). - richtext/schema.ts: PM schema (tiptap-named) + atomic `chip` node - richtext/astToProseMirror.ts: astToDoc — AST subtree -> PM doc, opaque constructs (math/cite/shortcode/raw) -> verbatim chips; list tightness from AST - richtext/serializer.ts: docToMarkdown — prosemirror-markdown rules re-keyed to tiptap names; qmd-aware italic->`_` (avoids disallowed `***`) - test-utils/pampaOracle.ts: shared native-pampa oracle; inline marks compared as a flat SET (ProseMirror model) so `[**x**](u)` == `**[x](u)**`; skips when the native binary can't be built - richtext/roundtrip.test.ts: 13 fixtures, all pass; gate is SEMANTIC equivalence (no dropped/changed nodes), byte-exactness informational Adds tiptap (core/pm/react/starter-kit) + prosemirror-markdown/model as preview-renderer deps. tsc clean; round-trip suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bd-sjb4pzx8) Wires the opt-in WYSIWYG tiptap editor into the q2-preview block-edit path. - richtext/RichTextEditor.tsx: tiptap editor seeded from the block's AST subtree (astToDoc -> JSON), committing markdown via the UNCHANGED commitTextEdit path; dirtiness from doc.eq(initialDoc) (C3, true no-op on unedited close); stale- target + focus-restore guards; Esc/Mod-Enter/blur; paragraph-only (Enter swallowed, no structural splits in 1a) - richtext/chipExtension.ts: tiptap Chip node (inline atom, verbatim pill) - richtext/styles.ts: one-time CSS — strip ProseMirror chrome, zero inner-block margin (measured box owns spacing), subtle chip pills; theme styles the rest - dispatchers.tsx: Block renders RichTextEditor (vs EditTextarea) for Para when ctx.richText; same measured box - richText flag plumbed like unlockNestingCursor: PreviewContext -> PreviewRoot -> entry -> Q2PreviewIframe (UPDATE_AST payload) -> SPA ?richText=1 tsc clean (preview-renderer + q2-preview-spa); full preview-renderer suite passes (486 tests). Browser verification next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Browser verification via q2 preview --allow-edit + ?richText=1: clicking a paragraph opens a tiptap editor visually identical to the rendered block (marks styled by the theme via the same-iframe CSS cascade); a real inline-bold edit committed through the unchanged commitTextEdit path and wrote clean qmd to disk with the rest of the paragraph round-tripped byte-clean. Adds Phase 1a evidence screenshots (rendered + editing) and marks Phase 1a complete in the plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The WYSIWYG render is faithful enough that users couldn't tell editing was live. Give the active rich-text editor a subtle blue background tint + ring so "edit mode" is obvious. Padding is offset by an equal negative margin so the text does not shift (zero reflow); marks stay theme-styled. Verified in q2 preview. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rce (bd-sjb4pzx8)
Two UX refinements + a source-mapping fix:
- "Editing…" affordance: a faint italic label parked in the LEFT MARGIN of the
active editor (pointer-events:none, user-select:none) so it signals edit mode
without hijacking text clicking/selecting. First of the left-margin affordances.
- Shortcode chip source: prefer a node's own `.l` literal location over the
compact pool entry when slicing chip text. The pool range for a shortcode Span
is mis-assigned (points at an adjacent space → empty chip); `.l` points at the
actual token, so the chip now renders the verbatim `{{< meta key >}}` monospace.
Leaf inlines (math/cite) were already correct via the pool; this fixes
container inlines. Round-trip suite still green (native pampa carries `.l` too).
Verified in q2 preview: Editing label + math/cite/shortcode chips all render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-place escape hatch to the textarea, parked in the left margin under "Editing…"
(absolute, off the text, so it never hijacks clicking/selecting).
- EditAffordance.tsx: shared left-margin affordance (label + rich/plain toggle),
rendered by renderMeasuredEdit so it shows for BOTH surfaces. Toggle uses
mousedown-preventDefault to keep editor focus.
- editorMode ('rich'|'plain') in PreviewRoot, session-sticky, default rich; the
dispatcher renders RichTextEditor vs EditTextarea accordingly.
- editorModeSwitchRef guard: a surface swap fires the outgoing editor's
unmount-blur, which must NOT commit/close the session — both blur handlers
(rich + textarea) check the ref. Without it, toggling closed the editor.
- rich->plain content handoff via editDraftRef (RichTextEditor.onUpdate keeps the
shared markdown draft current, dirty-aware so an untouched toggle never
reformats). plain->rich re-seeds from the original AST (in-iframe can't parse
edited markdown — Phase 2).
Verified in q2 preview: toggle rich<->plain keeps the session open, swaps
surfaces, preserves content. tsc clean; preview-renderer suite 486 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Header added to the rich-text supported types; heading node enabled in the
tiptap editor. The AST->PM bridge already mapped Header -> heading{level}, so
the round-trip was ready (added a heading-with-marks fixture; 14/14 green).
- enableInputRules/enablePasteRules false: 1b edits existing structure only —
typing "## " must not convert a paragraph or change a heading level (structural
edits are a later phase; Cmd-B/I marks still work).
- trailingNode false: a single-heading doc was getting a phantom empty trailing
paragraph (extra editor height + a stray blank block on commit). Fixed; the
heading edit box is now tight (verified in q2 preview).
tsc clean; preview-renderer suite 487 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ks (bd-sjb4pzx8)
A small toolbar floats above the top-left of the rich-text edit box:
- Mark buttons (bold/italic/strike/subscript/superscript) call toggleMark over the
selection (same command as Cmd-B/I), highlight via isActive, and use
mousedown-preventDefault so clicking never collapses the selection. Verified
end-to-end: select word + Bold -> **word** on disk.
- Subscript/superscript are now real marks (not chips): added
@tiptap/extension-{sub,super}script, schema marks, serializer (~x~ / ^x^), AST
mapping (Pandoc Subscript/Superscript -> marks). Round-trip fixture green (15/15).
- Link button opens a URL input; setLink / extendMarkRange('link') / unsetLink
(edit/remove an existing link by placing the cursor inside it). Commit was
rescoped to focusout from the whole edit box so focusing the link input doesn't
close the session.
KNOWN ISSUE (bd-3zp3z4jx, downstream): a NEW link's URL is corrupted on write-back
when the paragraph already has another link (gets the adjacent link's URL). The
rich editor commits CORRECT markdown (verified); the corruption is in the shared
text-channel write-back (apply_node_edit / incremental writer), so it affects the
textarea editor's link edits too. Single-link / no-other-link edits are fine.
tsc clean; preview-renderer suite 488 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-q9lyghv2) Clicking a paragraph/heading to open the tiptap rich-text editor in q2 preview --allow-edit (&richText=1) dropped the caret at end-of-block (autofocus:'end'); only a SECOND click landed it where the user clicked. Root cause: the open goes through a React state update, so the original mouse event is consumed before the editor mounts — ProseMirror never gets to translate the click into a doc position. Capture the activating mouse click's viewport coords and replay them at mount via posAtCoords: - New pendingClickCoordsRef on PreviewContext (allocated in PreviewRoot). - useBlockEditHover threads the coords into activate() at the single open chokepoint (right before setEditTarget). Mouse passes coords; keyboard/touch pass none -> ref nulled (keeps end-of-block, also clears any stale coords). One site covers both fresh-open and click-switch. - RichTextEditor reads+clears the ref once at mount and places the caret via the new placeCaretFromClick helper; autofocus:'end' remains the fallback (keyboard/touch, or posAtCoords miss). Read-once means a self-heal re-anchor remount falls back to end-of-block rather than replaying a now-stale click. Tests (jsdom verifies the capture->consume->fallback wiring; geometry is browser-verified separately): - useBlockEditHover.caret-coords.integration: coords captured on mouse, not keyboard/touch. - caretFromClick.test: posAtCoords hit -> setTextSelection; miss -> false. - RichTextEditor.caret.integration: ref consumed/cleared at mount. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s (bd-q9lyghv2)
Browser verification of the caret-at-click fix revealed that the caret still
pinned to end-of-block on the first click. Root cause was NOT geometry
(caretRangeFromPoint resolved the clicked position immediately) but a race:
tiptap's autofocus:'end' applies its end-selection inside a requestAnimationFrame
that lands on the same frame as our placement and beats it.
Fix: set autofocus:false and have the mount effect own the opening caret as the
single source of truth — place at the click via posAtCoords when coords were
captured, else focus('end') (the historical default). The placement runs in a
requestAnimationFrame (layout settled) and, with autofocus off, nothing competes.
Also polyfill getClientRects/getBoundingClientRect on Text and Range in the
preview-renderer test setup: mounting a tiptap editor in jsdom drives
ProseMirror's coordsAtPos (via focus -> scrollIntoView), which jsdom doesn't
implement on those node types. Geometry is meaningless in jsdom (browser-verified
separately); the stub keeps the editor's focus machinery from throwing.
End-to-end verified in q2 preview --allow-edit (&richText=1): mouse clicks at
40%/10% of a paragraph and 50% of a heading land the caret exactly at the click
(delta 0/-1); clean editor-to-editor click-switch lands at the click; keyboard
activation still lands at end-of-block. See the plan doc for the result table and
screenshot (claude-notes/richtext-shots/14-caret-at-click.png).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…em> italics (bd-dg8x84bu)
RevealDeck.tsx imported reveal.js's reset.css as a side-effecting CSS module.
Bundlers hoist every CSS import in the module graph into the q2-preview SPA's
single global stylesheet, regardless of whether the importing component renders.
reset.css is reveal's global Meyer reset (html, body, ..., em, i, cite,
var { font: inherit; ... }), so it applied to ALL preview content — including
format:html documents with no deck — resetting font-style on <em>/<i>/<cite> to
inherit and overriding the UA default em{font-style:italic}. Result: emphasis
rendered upright in q2 preview and hub-client. (Verified: computed font-style on
a preview <em> was "normal"; after the fix it is "italic".)
reveal.css and quarto-reveal.css are reveal-namespaced (no bare element-type
selectors), so they don't leak. reset.css was the sole offender.
Fix: add resources/revealjs/reset-scoped.css — the same Meyer reset with every
selector scoped under .reveal (derived from reset.css; reset.css itself is left
byte-identical to npm for the vendoring check). RevealDeck.tsx and the q2-debug
entry import the scoped version. Deck slide content (all under .reveal) gets the
identical reset; non-deck content is untouched.
Regression guard: reveal-reset-scope.test.ts asserts none of the three reveal
CSS files RevealDeck imports carries a global bare-element selector.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…jb4pzx8) Flip the q2-preview SPA boot flag so the tiptap rich-text editor is ON by default; only an explicit ?richText=0 opts out (previously it was opt-in via ?richText=1). parseRichTextParam now returns true for an absent param / any value other than "0". Scope: this changes only the q2 preview SPA default. The richText flag is still off unless a host sets it at the PreviewContext level, so hub-client behavior is unchanged (it does not read the boot param). Tests: parseRichTextParam.test.ts (exported the helper) covers default-on, ?richText=1 on, ?richText=0 off, other values on, and mixed params. Verified end-to-end in q2 preview --allow-edit: no param mounts .ProseMirror; ?richText=0 mounts the plain textarea. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d-j1nto6eq)
The tiptap rich-text block editor was wired in the q2-preview iframe
(Q2PreviewIframe already forwards a `richText` flag into the UPDATE_AST
payload → PreviewContext.richText) and enabled in the standalone q2 preview
SPA, but hub-client never passed the flag, so it always used the textarea.
Plumb `richText` from a new user preference (default ON) through to the iframe,
mirroring the `unlockNestingCursor` preference end-to-end:
- preferences/schema.ts: richText z.boolean().default(true) + DEFAULT_PREFERENCES.
The .default(true) keeps prefs written before this key existed parsing cleanly
(no migration / version bump) — guarded by a regression test mirroring the
unlockNestingCursor one.
- ReactPreview: usePreference('richText') → passed to ReactRenderer.
- ReactRenderer: new richText prop forwarded to Q2PreviewIframe only (q2-debug
and slides ignore it, like unlockNestingCursor).
- SettingsTab: "Rich-text editor" toggle in the Preview section (opt-out;
takes effect live via the preference-change broadcast).
Q2PreviewIframe needs no change (already wired).
Tests (TDD, red-first): schema.test.ts (default-on + old-prefs-without-key
preserved); ReactRenderer.integration.test.tsx (richText forwarded to the
mocked Q2PreviewIframe). Full hub-client suites green (660 unit + 76
integration); build:all passes.
End-to-end: not driven as a full authenticated hub session (needs backend +
auth + project); verified by layers — the iframe→PreviewContext→tiptap path is
the same component already verified end-to-end in the q2 preview SPA. See
claude-notes/plans/2026-06-24-hub-client-richtext-preference.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…38tnyqy)
Enabling the rich-text editor by default (bd-j1nto6eq) broke ~25 q2-preview
editing e2e specs: they click a block and waitFor('textarea'), but the rich
editor (.ProseMirror) now opens instead, so the textarea locator times out.
These specs target the plain-textarea inline editor specifically (caret-column
geometry, visual-line nav, nesting cursor, delete-by-emptying, self-heal, ...),
so they should run with richText off — exactly as they already pin
unlockNestingCursor. Fix at the single chokepoint every editing spec routes
through (its openFile -> bootstrapProjectSet): an addInitScript that MERGES
richText:false into the seeded preferences only when a spec hasn't set it
explicitly. A spec's own beforeEach preference seed runs first and is preserved
(richText filled in as false); a spec with no seed gets the full default object
(incl. the schema-required `version`) with richText:false; a future rich-text
spec can opt IN by seeding richText:true. No spec files touched.
Verified locally (the e2e harness starts its own hub + serves the build):
inline-edit (seeds prefs) + delete-by-emptying (no seed) → 9 passed; block-nav,
breadcrumb-geometry, expand-on-edit, self-heal → 25 passed / 1 skipped. Both
merge paths exercised.
Not addressed (pre-existing, unrelated to richText): q2-debug-render-components,
share-link-project-set (expect.poll timeout), render-components-comment/kanban
(Path-not-found / EDITOR_NO_PREVIEW) — infra/peer-connection flakiness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Experimental, opt-in WYSIWYG block editor for
q2 preview/ quarto-hub, built on tiptap/ProseMirror. Strand bd-sjb4pzx8. Plan:claude-notes/plans/2026-06-23-tiptap-rich-text-block-editor.md.This is a feasibility experiment on a branch — it does not change default behavior. The rich editor only activates behind
?richText=1(+q2 preview --allow-edit). With the flag off, everything is byte-identical to today's monospaced textarea.What it does
Clicking an editable paragraph opens a tiptap rich-text editor in place of the textarea, inside the same measured box and committing through the unchanged
commitTextEdit→parse_qmd_content→ splice → write-back path. Because the editor lives in the preview iframe (which already has the Bootstrap + theme CSS), its semantic tags (<p>/<em>/<strong>/<a>) are styled by the theme automatically — so editing looks like the rendered page.@crossref,[@cite], raw inline) become verbatim "chip" atoms.doc.eq(untouched open/close is a true no-op).Scope (Phase 1a)
Tests / verification
richtext/roundtrip.test.ts, 13 fixtures) — gate is semantic AST equivalence (no dropped/changed nodes); cosmetic reformatting allowed. Skips gracefully when nativepampacan't be built.tscclean across preview-renderer, q2-preview-spa, hub-client (tsc -b+ vite + sandboxed iframe build).q2 preview --allow-editwith chrome-devtools (screenshots inclaude-notes/richtext-shots/), including a faithful real edit writing clean qmd back to disk.Notes for review
preview-rendererdeps.ts-packages/preview-renderer/src/q2-preview/tiptap-roundtrip-spike/is a throwaway Phase-0 spike (marked as such); safe to delete before any non-experimental merge.🤖 Generated with Claude Code